package com.xrath.rtm;

import java.io.*;
import java.net.*;
import java.text.ParseException;
import java.util.*;
import java.util.logging.*;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import javax.xml.parsers.*;
import javax.xml.xpath.*;

import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.w3c.dom.*;

/**
 * The main handler for rememberthemilk api.
 * Each request was delayed in <b>1 second</b>, or RTM may throttle your API keys. 
 * <p>Following examples will help you.</p>
 *
 * <b>Get a frob, Authorize your application key and Take a token</b>
 * <pre>
 * RememberTheMilk rtm = new RememberTheMilk();
 * rtm.setAPIKey("1234");
 * rtm.setSharedSecret("secret");
 * 
 * String frob = rtm.<span style="color: #0000ff;">getFrob()</span>;
 * URL url = rtm.<span style="color: #0000ff;">getAuthenticationURL("delete", frob);</span>
 * 
 * Console c = System.console();
 * c.printf("Confirm the following URL: %s%n", url);
 * c.printf("Done? ");
 * new Scanner(System.in).nextLine();
 *
 * String token = rtm.<span style="color: #0000ff;">createToken(frob);</span> // get it!
 * </pre>
 * 
 * <b>Get a list of lists</b>
 * <pre>
 * RememberTheMilk rtm = new RememberTheMilk();
 * rtm.setAPIKey("1234");
 * rtm.setSharedSecret("secret");
 * rtm.setToken("xxxx");
 *
 * List&lt;TaskList&gt; lists = rtm.<span style="color: #0000ff;">getTaskLists();</span>
 * for(TaskList list : lists) {
 *   c.printf("%8d - %s%n", list.getId(), list.getName());
 * }
 * </pre>
 * 
 * <b>Get a list of incomplete tasks</b>
 * <pre>
 * RememberTheMilk rtm = new RememberTheMilk();
 * rtm.setAPIKey("1234");
 * rtm.setSharedSecret("secret");
 * rtm.setToken("xxxx");
 * 
 * TaskList list = ...
 *
 * List&lt;Task&gt; tasks = rtm.<span style="color:#0000ff;">getTasks(list, "status:incomplete");</span>
 * for(Task task : tasks) { 
 *   c.printf("name=%s, added=%s, priority=%d, tags=%s%n", 
 *     task.getName(), task.getAdded(), task.getPriority(), task.getTags());
 *   for(Note note : task.getNotes()) {
 *     c.printf("  Note[%s]%n", note.getTitle());
 *     c.printf("    %s%n", note.getText());
 *   }
 * }
 * </pre>
 * 
 * <b>Add a new task to list</b>
 * <pre>
 * RememberTheMilk rtm = new RememberTheMilk();
 * rtm.setAPIKey("1234");
 * rtm.setSharedSecret("secret");
 * rtm.setToken("xxxx");
 *
 * TaskList listToAdd = ...
 * Calendar cal = Calendar.getInstance();
 * cal.add( Calendar.DAY_OF_MONTH, 3 ); // to use on estimating
 * 
 * Task task = new Task("Get a pineapple");
 * task.setDue(cal.getTime());
 * task.setEstimate("30 min");
 * task.addTag("home");
 * task.setPriority(2);
 * task.addNote(new Note("Take a FRESH pineaple!", "hehe"));
 *
 * rtm.<span style="color: #0000ff;">addTask(listToAdd, task);</span>
 * </pre>
 * 
 * @author Jang-Ho Hwang, rath@xrath.com
 * @version 1.0, since 2008/06/01
 */
public class RememberTheMilk
{
	private String apiKey;
	private String sharedSecret;

	private String token;
	private String timeline;

	private final Logger log;
	private final DocumentBuilder docBuilder;
	private final XPathFactory xpathFactory;

	private XPathExpression xpathErrorCheck;
	private XPathExpression xpathErrorCode;
	private XPathExpression xpathErrorMessage;

	private long lastCallTime;

	/**
	 * Create a remember the milk handler.
	 */
	public RememberTheMilk() throws FactoryConfigurationError, ParserConfigurationException
	{
		this.log = Logger.getLogger(this.getClass().getName());
		this.docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
		this.xpathFactory = XPathFactory.newInstance();

		try {
			xpathErrorCheck = xpathFactory.newXPath().compile("/rsp/@stat");
			xpathErrorCode = xpathFactory.newXPath().compile("//err/@code");
			xpathErrorMessage = xpathFactory.newXPath().compile("//err/@msg");
		} catch( XPathExpressionException e ) {
			log.throwing(getClass().getName(), "constructor", e);
		}
	}

	/**
	 * Set the API Key to use rememberthemilk object. 
	 * see also <a href="http://www.rememberthemilk.com/services/api/keys.rtm">API Keys</a>.
	 */
	public void setAPIKey( String apiKey )
	{
		this.apiKey = apiKey;
	}

	/**
	 * Return the API Key to use rememberthemilk object. 
	 * see also <a href="http://www.rememberthemilk.com/services/api/keys.rtm">API Keys</a>.
	 */
	public String getAPIKey()
	{
		return this.apiKey;
	}

	/**
	 * Set the shared secret to sign each request. 
	 * see also <a href="http://www.rememberthemilk.com/services/api/keys.rtm">API Keys</a>.
	 */
	public void setSharedSecret( String secret )
	{
		this.sharedSecret = secret;
	}

	/**
	 * Return the shared secret to sign each request. 
	 * see also <a href="http://www.rememberthemilk.com/services/api/keys.rtm">API Keys</a>.
	 */
	public String getSharedSecret()
	{
		return this.sharedSecret;
	}

	/**
	 * Set the auth token. it can be get with 
	 * {@link com.xrath.rtm.RememberTheMilk#createToken createToken} method.
	 */
	public void setToken( String token )
	{
		this.token = token;
	}

	/**
	 * Return the auth token.
	 */
	public String getToken()
	{
		return this.token;
	}

	/**
	 * Set the timeline. it can be get with 
	 * {@link com.xrath.rtm.RememberTheMilk#createTimeline createTimeline} method.
	 *
	 */
	public void setTimeline( String timeline )
	{
		this.timeline = timeline;
	}

	/**
	 * Return the timeline. it can be create with 
	 * {@link com.xrath.rtm.RememberTheMilk#createTimeline createTimeline} method.
	 */
	public String getTimeline()
	{
		return this.timeline;
	}

	private String sign( String... keyValues ) throws IOException
	{
		HashMap<String, String> map = new HashMap<String, String>();
		for(int i=0; i<keyValues.length; i+=2)
			map.put( keyValues[i], keyValues[i+1] );
		
		return sign(map);
	}

	private String sign( Map<String, String> params ) throws IOException
	{
		params.remove( "api_sig" );

		List<String> keys = new ArrayList<String>(params.keySet());
		Collections.sort(keys);

		StringBuilder sb = new StringBuilder();
		for(String key: keys) {
			sb.append(key);
			sb.append(params.get(key));
		}
		sb.insert(0, sharedSecret);

		MessageDigest md5 = null;
		try {
			md5 = MessageDigest.getInstance("MD5");
		} catch( NoSuchAlgorithmException e ) {
			throw new IOException(e);
		}
		String api_sig = getHex(md5.digest( sb.toString().getBytes("UTF-8") ));

		params.put( "api_sig", api_sig );
		log.finer( "calculated api signature=" + api_sig );

		return api_sig;
	}

	private String getHex( byte[] data )
	{
		StringBuilder sb = new StringBuilder();
		for(byte b : data) {
			int v = (int)b;
			if( v < 0 )
				v += 0x100;

			String s = Integer.toHexString(v);
			if( s.length()==1 )
				sb.append('0');
			sb.append(s);
		}
		return sb.toString();
	}

	/**
	 * INTERNAL USE ONLY.
	 */
	protected Document request( String methodName, Map<String, String> params ) throws IOException
	{
		if( apiKey==null )
			throw new IllegalArgumentException("api_key was not set. please call setAPIKey(String).");

		log.fine( "call " + methodName + ", " + params );

		params.put( "method", methodName );
		params.put( "api_key", apiKey );
		sign(params);

		StringBuilder sb = new StringBuilder();
		for(String key : params.keySet()) {
			sb.append(key);
			sb.append('=');
			sb.append(URLEncoder.encode(params.get(key), "UTF-8"));
			sb.append('&');
		}

		URL url = new URL("http://api.rememberthemilk.com/services/rest/?" + sb.toString());
		Document doc = requestImpl(url);

		String stat = null;
		try {
			stat = xpathErrorCheck.evaluate(doc);
		} catch( XPathExpressionException e ) {} 

		if( stat.equals("fail") ) {
			String code = "";
			String message = "";
			try {
				code = xpathErrorCode.evaluate(doc);
				message = xpathErrorMessage.evaluate(doc);
			} catch( XPathExpressionException e ) {} 

			throw new RTMException( message + ", code=" + code );
		}
		return doc;
	}

	/**
	 * create an authentication url for use authentication your api application.
	 *
	 * @param perm   read, write, delete
	 * @param frob   rtm.auth.getFrob
	 */
	public URL getAuthenticationURL( String perm, String frob ) throws IOException
	{
		if( apiKey==null )
			throw new IllegalArgumentException("api_key was not set. please call setAPIKey(String).");

		HashMap<String, String> params = new HashMap<String, String>();
		params.put( "perms", perm.toLowerCase() );
		params.put( "frob", frob );
		params.put( "api_key", apiKey );
		sign(params);

		StringBuilder sb = new StringBuilder();
		for(String key : params.keySet()) {
			sb.append(key);
			sb.append('=');
			sb.append(params.get(key));
			sb.append('&');
		}
		URL url = new URL("http://www.rememberthemilk.com/services/auth/?" + sb.toString());
		return url;
	}

	/**
	 * INTERNAL USE ONLY.
	 */
	protected Document requestImpl( URL url ) throws IOException
	{
		long now = System.currentTimeMillis();
		if( now-1000L < this.lastCallTime ) {
			try {
				long delay = 1000L - (now-lastCallTime);
				log.info("wait to next request for avoiding throttle (" + delay + "ms)");
				Thread.sleep(delay);
			} catch( InterruptedException e ) {}
		}

		log.finer( "request " + url );

		HttpURLConnection con = (HttpURLConnection)url.openConnection();
		ByteArrayOutputStream bos = new ByteArrayOutputStream();
		InputStream in = null;
		byte[] buf = new byte[4096];
		try {
			in = con.getInputStream();
			while(true) {
				int readlen = in.read(buf);
				if( readlen < 1 )
					break;
				bos.write(buf, 0, readlen);
			}
		} finally {
			if( in!=null )
				in.close();
		}

		String result = new String(bos.toByteArray(), "UTF-8");
		log.finest( "response text\n" + result);
		Document doc = null;
		try {
			doc = docBuilder.parse( new InputSource(new StringReader(result)) );
		} catch( SAXException e ) {
			log.severe("xml parse error\n" + result);
			throw new IOException(e);
		}

		this.lastCallTime = System.currentTimeMillis();

		return doc;
	}

	/**
	 * Request a frob and return it.
	 */
	public String getFrob() throws IOException
	{
		Map<String, String> params = new HashMap<String, String>();

		Document doc = request("rtm.auth.getFrob", params);
		return xpathEval("//frob/text()", doc);
	}

	/**
	 * Create and get a token with given frob. 
	 */
	public String createToken( String frob ) throws IOException
	{
		Map<String, String> params = new HashMap<String, String>();
		params.put("frob", frob);

		Document doc = request("rtm.auth.getToken", params);

		this.token = xpathEval("//token/text()", doc);
		log.info( "auth_token created. " + token );
		return token;
	}

	/**
	 * Create and get a <a href="http://www.rememberthemilk.com/services/api/timelines.rtm">timeline</a>.
	 */
	public String createTimeline() throws IOException
	{
		checkAuthToken();

		Map<String, String> params = new HashMap<String, String>();
		params.put("auth_token", this.token);

		Document doc = request("rtm.timelines.create", params);

		this.timeline = xpathEval("//timeline/text()", doc);
		log.fine( "timeline created. " + timeline );
		return token;
	}

	/**
	 * Add a tasklist with given name and return the created list.
	 */
	public TaskList addTaskList( String listName ) throws IOException
	{
		checkAuthToken();
		checkTimeline();
		
		Map<String, String> params = new HashMap<String, String>();
		params.put("auth_token", this.token);
		params.put("timeline", this.timeline);
		params.put("name", listName);

		Document doc = request("rtm.lists.add", params);
		NodeList lists = doc.getElementsByTagName("list");						
		Element elem = (Element)lists.item(0);

		TaskList l = new TaskList();
		l.setId( Integer.parseInt(elem.getAttribute("id")) );
		l.setName( elem.getAttribute("name") );

		return l;
	}

	/**
	 * Remove the tasklist that has same id of given tasklist.
	 */
	public TaskList removeTaskList( TaskList list ) throws IOException
	{
		checkAuthToken();
		checkTimeline();
		
		Map<String, String> params = new HashMap<String, String>();
		params.put("auth_token", this.token);
		params.put("timeline", this.timeline);
		params.put("list_id", String.valueOf(list.getId()));

		Document doc = request("rtm.lists.delete", params);
		NodeList lists = doc.getElementsByTagName("list");						
		Element elem = (Element)lists.item(0);

		list.setId( Integer.parseInt(elem.getAttribute("id")) );
		list.setName( elem.getAttribute("name") );

		return list;
	}

	/**
	 * Retrieves a list of tasklist(lists).
	 */
	public List<TaskList> getTaskLists() throws IOException
	{
		checkAuthToken();

		Map<String, String> params = new HashMap<String, String>();
		params.put("auth_token", this.token);

		List<TaskList> ret = new ArrayList<TaskList>();

		Document doc = request("rtm.lists.getList", params);
		NodeList lists = doc.getElementsByTagName("list");
		for(int i=0; i<lists.getLength(); i++) {
			Element elem = (Element)lists.item(i);
			
			TaskList l = new TaskList();
			l.setId( Integer.parseInt(elem.getAttribute("id")) );
			l.setName( elem.getAttribute("name") );

			ret.add(l);
		}
		return ret;
	}

	/**
	 * Rename a given tasklist.
	 */
	public void renameTaskListName( TaskList list ) throws IOException
	{
		checkAuthToken();
		checkTimeline();
		
		Map<String, String> params = new HashMap<String, String>();
		params.put("auth_token", this.token);
		params.put("timeline", this.timeline);
		params.put("list_id", String.valueOf(list.getId()));
		params.put("name", list.getName());

		Document doc = request("rtm.lists.setName", params);
		NodeList lists = doc.getElementsByTagName("list");						
		Element elem = (Element)lists.item(0);

		list.setId( Integer.parseInt(elem.getAttribute("id")) );
		list.setName( elem.getAttribute("name") );
	}

	/**
	 * Get all tasks in specified taskList.
	 */
	public List<Task> getTasks( TaskList taskList ) throws IOException, ParseException
	{
		return getTasks(taskList, null, null);
	}

	/**
	 * Get all tasks in specified taskList and filter.
	 *
	 * @param filter  filter that getting tasks. see also <a href="http://www.rememberthemilk.com/help/answers/search/advanced.rtm">How do I use the advanced search?</a>
	 */
	public List<Task> getTasks( TaskList taskList, String filter ) throws IOException, ParseException
	{
		return getTasks(taskList, filter, null);
	}

	/**
	 * Get all tasks in specified taskList, filter, last_sync.
	 *
	 * @param filter  filter that getting tasks. see also <a href="http://www.rememberthemilk.com/help/answers/search/advanced.rtm">How do I use the advanced search?</a>
	 * @param lastSync if last_sync is provided, only tasks modified since 
	 * last_sync will be returned, and each list element will have an attribute, 
	 * current equals to last_sync.
	 */
	public List<Task> getTasks( TaskList taskList, String filter, Date lastSync ) throws IOException, ParseException
	{
		checkAuthToken();

		DateFormatter fmt = new DateFormatter();

		Map<String, String> params = new HashMap<String, String>();
		params.put("auth_token", this.token);
		params.put("list_id", String.valueOf(taskList.getId()));
		if( filter!=null ) {
			params.put("filter", filter);
		}
		if( lastSync!=null ) {
			params.put("last_sync", fmt.format(lastSync));
		}

		List<Task> ret = new ArrayList<Task>();

		Document doc = request("rtm.tasks.getList", params);
		NodeList taskSeriesList = doc.getElementsByTagName("taskseries");	
				
		for(int i=0; i<taskSeriesList.getLength(); i++) {
			Element elem = (Element)taskSeriesList.item(i);

			Task task = new Task();
			
			String seriesId = elem.getAttribute("id");
			String name = elem.getAttribute("name");
			String created = elem.getAttribute("created");
			String modified = elem.getAttribute("modified");
			String source = elem.getAttribute("source");
			String url = elem.getAttribute("url");

			task.setSeriesId( seriesId );
			task.setName( name );
			if( url.length()>0 )
				task.setURL(new URL(url));

			fmt.set(task, "created", created);
			fmt.set(task, "modified", modified);

			NodeList tags = elem.getElementsByTagName("tag");
			for(int x=0; x<tags.getLength(); x++) {
				Element elemTag = (Element)tags.item(x);
				String tag = elemTag.getFirstChild().getNodeValue();

				task.addTag(tag);
			}

			NodeList contacts = elem.getElementsByTagName("contact");
			for(int x=0; x<contacts.getLength(); x++) {
				Element elemContact = (Element)contacts.item(x);
				String userId = elemContact.getAttribute("id");
				String userFullname = elemContact.getAttribute("fullname");
				String username = elemContact.getAttribute("username");

				Contact user = new Contact();
				user.setId( userId );
				user.setUsername( username );
				user.setFullname( userFullname );

				task.addParticipant( user );
			}

			NodeList notes = elem.getElementsByTagName("note");
			for(int x=0; x<notes.getLength(); x++) {
				Element elemNote = (Element)notes.item(x);
				String noteId = elemNote.getAttribute("id");
				String noteTitle = elemNote.getAttribute("title");
				String text = elemNote.getFirstChild().getNodeValue();
				String noteCreated = elemNote.getAttribute("created");
				String noteModified = elemNote.getAttribute("modified");

				Note note = new Note();
				note.setId( noteId );
				note.setTitle( noteTitle );
				note.setText( text );

				fmt.set(note, "created", noteCreated);
				fmt.set(note, "modified", noteModified);

				task.addNote( note );
			}

			Element elemTask = (Element)elem.getElementsByTagName("task").item(0);

			String id = elemTask.getAttribute("id");
			String due = elemTask.getAttribute("due");
			String added = elemTask.getAttribute("added");
			String completed = elemTask.getAttribute("completed");
			String deleted = elemTask.getAttribute("deleted");

			String prior = elemTask.getAttribute("priority");
			String postponed = elemTask.getAttribute("postponed");
			String estimate = elemTask.getAttribute("estimate");

			task.setId( elemTask.getAttribute("id") );
			if( prior.charAt(0)!='N' )
				task.setPriority( Integer.parseInt(prior) );
			task.setPostponed( Integer.parseInt(postponed) );
			task.setEstimate( estimate );

			fmt.set(task, "added", added);
			fmt.set(task, "due", due);
			fmt.set(task, "completed", completed);
			fmt.set(task, "deleted", deleted);

			ret.add( task );
		}

		return ret;
	}

	/**
	 * Add specified task(toAdd) into taskList, if you set <b>name</b>, <b>url</b>, <b>due</b>, <b>priority</b>, <b>estimate</b>, <b>tags</b> and <b>notes</b>, it will apply to added task. 
	 *
	 * @param taskList  taskList to add a task.
	 * @param toAdd  task to add.
	 * @return added task. it includes id, seriesId, created, modified, added.
	 */
	public Task addTask( TaskList taskList, Task toAdd ) throws IOException, ParseException
	{
		if( toAdd.getName()==null )
			throw new IllegalArgumentException("task 'name' must be set");

		checkAuthToken();
		checkTimeline();

		toAdd.setTaskList(taskList);

		DateFormatter fmt = new DateFormatter();

		Map<String, String> params = new HashMap<String, String>();
		params.put("auth_token", this.token);
		params.put("timeline", this.timeline);
		params.put("list_id", String.valueOf(taskList.getId()));
		params.put("name", toAdd.getName());
		params.put("parse", "0");

		Document doc = request("rtm.tasks.add", params);
		
		toAdd.setSeriesId( xpathEval("//taskseries/@id", doc) );
		toAdd.setId( xpathEval("//task/@id", doc) );

		fmt.set(toAdd, "created", xpathEval("//taskseries/@created", doc));
		fmt.set(toAdd, "modified", xpathEval("//taskseries/@modified", doc));
		fmt.set(toAdd, "added", xpathEval("//task/@added", doc));

		addTaskProperties(toAdd, params);

		return toAdd;
	}

	/**
	 * Set specified task properties. (<b>url</b>, <b>due</b>, <b>priority</b>, <b>estimate</b>, <b>tags</b> and <b>notes</b>)
	 * 
	 */
	protected void addTaskProperties( Task task, Map<String, String> params ) throws IOException
	{
		DateFormatter fmt = new DateFormatter();
		
		params.remove("name");
		params.remove("parse");

		checkAuthToken();
		checkTimeline();

		params.put("auth_token", this.token);
		params.put("timeline", this.timeline);
		params.put("list_id", String.valueOf(task.getTaskList().getId()));
		params.put("taskseries_id", task.getSeriesId());
		params.put("task_id", task.getId());

		if( task.getURL()!=null )
			params.put("url", task.getURL().toString());
		request("rtm.tasks.setURL", params);
		params.remove("url");

		if( task.getDue()!=null )
			params.put("due", fmt.format(task.getDue()));
		request("rtm.tasks.setDueDate", params);
		params.remove("due");

		if( task.getPriority()!=0 )
			params.put("priority", String.valueOf(task.getPriority()));
		request("rtm.tasks.setPriority", params);
		params.remove("priority");

		if( task.getEstimate()!=null )
			params.put("estimate", task.getEstimate());
		request("rtm.tasks.setEstimate", params);
		params.remove("estimate");

		StringBuilder sb = new StringBuilder();
		for(Iterator<String> i=task.getTags().iterator(); i.hasNext();)
		{
			String tag = i.next();
			sb.append(tag);
			if( i.hasNext() )
				sb.append(",");
		}
		params.put("tags", sb.toString());
		request("rtm.tasks.setTags", params);
		params.remove("tags");

		for(Note note : task.getNotes())
		{
			params.put("note_title", note.getTitle());
			params.put("note_text", note.getText());
			request("rtm.tasks.notes.add", params);
		}
		params.remove("note_title");
		params.remove("note_text");
	}

	private String xpathEval( String expr, Document doc )
	{
		String str = null;
		try {
			str = xpathFactory.newXPath().evaluate(expr, doc);
		} catch( XPathExpressionException e ) {
			throw new RuntimeException(e);
		}
		return str;
	}

	private void checkAuthToken() 
	{
		if( token==null )
			throw new RTMException("auth token required. need a permission.");
	}

	private void checkTimeline()
	{
		if( timeline==null )
			throw new RTMException("timeline required. create timeline first.");
	}
}
